"""Configure GitLab CODEOWNERS files."""

import argparse
import contextlib
import dataclasses
import difflib
import functools
import hashlib
import pathlib
import subprocess
import sys
import tempfile
import typing

from cki_lib import footer
from cki_lib import gitlab
from cki_lib import misc
from cki_lib import yaml
from cki_lib.logger import get_logger
import sentry_sdk

LOGGER = get_logger('cki_tools.gitlab_codeowners_config')


def split_lines(lines: str) -> typing.List[str]:
    """Split a string at the newlines, stripping any whitespace."""
    return lines.strip().split('\n')


def join_lines(lines: typing.Iterable[str]) -> str:
    """Join a list of lines, with a single final newline."""
    return '\n'.join(lines).strip() + '\n'


_CONFIG_BEGIN = '# GITLAB-CODEOWNERS-CONFIG-BEGIN'
_CONFIG_END = '# GITLAB-CODEOWNERS-CONFIG-END'
_GENERATED_BEGIN = '# GITLAB-CODEOWNERS-GENERATED-BEGIN'
_GENERATED_END = '# GITLAB-CODEOWNERS-GENERATED-END'


@dataclasses.dataclass
class Config:
    """Store the configuration."""

    path: pathlib.Path
    manual_lines: typing.List[str]
    config_lines: typing.List[str]
    config_checksum: str
    general: typing.Any
    sections: typing.Any
    read_checksum: str

    @staticmethod
    def load(path: pathlib.Path) -> 'Config':
        """Read a CODEOWNERS file and split it."""
        lines = split_lines(path.read_text(encoding='utf8'))

        config_line_begin = lines.index(_CONFIG_BEGIN)
        config_line_end = lines.index(_CONFIG_END)
        config_lines = lines[config_line_begin:config_line_end + 1]
        del lines[config_line_begin:config_line_end + 1]
        config_checksum = hashlib.sha256(join_lines(config_lines).encode('utf8')).hexdigest()
        config = yaml.load(contents=join_lines(ll[2:] for ll in config_lines[1:-1]))

        if _GENERATED_BEGIN in lines:
            generated_line_begin = lines.index(_GENERATED_BEGIN)
            generated_line_end = lines.index(_GENERATED_END)
            read_checksum = lines[generated_line_begin + 1][2:]
            del lines[generated_line_begin:generated_line_end + 1]
        else:
            read_checksum = ''

        return Config(path, lines, config_lines, config_checksum,
                      config['general'], config['sections'], read_checksum)


class CodeOwnersConfig:
    """Configure GitLab CODEOWNERS files."""

    def __init__(
        self,
        config_path: pathlib.Path,
        test_path: pathlib.Path,
    ):
        """Create a new checker."""
        self.config = Config.load(config_path)

        if test_path:
            self.test_data = [t for t in yaml.load(file_path=test_path)
                              if t['origin'] == 'kernel_public_tests']
        else:
            self.test_data = []

        self.gl_instance, self.gl_project = gitlab.parse_gitlab_url(
            self.config.general['project_url'])
        # https://rednafi.github.io/reflections/dont-wrap-instance-methods-with-functoolslru_cache-decorator-in-python.html
        self.user_names = functools.lru_cache()(self._user_names)
        self.known_users: typing.Set[str] = set()

    def _user_names(self, group_or_user: typing.Optional[str]) -> typing.Set[str]:
        """Return the user names for a user or group name."""
        if not group_or_user:
            return set()
        if group_or_user in self.known_users:
            return {group_or_user}
        with contextlib.suppress(Exception):
            gl_group = self.gl_instance.groups.get(group_or_user, lazy=True)
            users = {m.username for m in gl_group.members.list(iterator=True)}
            self.known_users.update(users)
            return users
        self.known_users.add(group_or_user)
        return {group_or_user}

    @staticmethod
    @contextlib.contextmanager
    def _tempfile(prefix: str, contents: str) -> typing.Generator[str, None, None]:
        """Create a temporary yaml file and return the name."""
        with tempfile.NamedTemporaryFile(mode='w+t', encoding='utf8',
                                         prefix=prefix, suffix='.yml') as temp:
            temp.write(contents)
            temp.flush()
            yield temp.name

    def generate_section(
        self,
        section_name: str,
        section_config: typing.Any,
        all_members: typing.Set[str],
    ) -> typing.List[str]:
        """Generate a section of the new CODEOWNERS file."""
        lines = [f'[{section_name}]']
        minimum_count = section_config.get('minimum_count', 1)

        section_users = set()
        for user in section_config.get('users', []):
            section_users.update(self.user_names(user))

        section_paths = set(section_config.get('paths', []))
        if section_config.get('kpet') == 'locations':
            section_paths.update(f'/{t["location"]}/' for t in self.test_data)

        section = {p: set(section_users) for p in section_paths}

        if section_config.get('kpet') == 'maintainers':
            for test in self.test_data:
                section.setdefault(f'/{test["location"]}/', set()).update(section_users)
                for user in test.get('maintainers', []):
                    section[f'/{test["location"]}/'].update(self.user_names(user.get('gitlab')))

        for path, users in sorted(section.items()):
            if len(effective_users := sorted(users & all_members)) >= minimum_count:
                lines.append(f'{path} {" ".join(f"@{u}" for u in effective_users)}')

        return lines

    def all_members(self) -> typing.Set[str]:
        """Return all eligible members of the project."""
        members = self.gl_project.members_all.list(all=True, per_page=100)
        self.known_users.update(m.username for m in members)
        return {m.username for m in members
                if m.access_level >= self.config.general['access_level']
                and not m.username.endswith('-bot')}

    def update_comment(
        self,
        comment_mr_iid: int,
        body: typing.Optional[str],
    ) -> None:
        """Add, update or remove a comment with the given content."""
        self.gl_instance.auth()
        username = self.gl_instance.user.username
        gl_mr = self.gl_project.mergerequests.get(comment_mr_iid)
        # full list to prevent interference from delete()
        for gl_note in gl_mr.notes.list(all=True):
            if not gl_note.system and gl_note.author['username'] == username:
                gl_note.delete()
        if body:
            gl_mr.notes.create({'body': body + footer.Footer().gitlab_footer()})

    def generate(self) -> str:
        """Generate and return a new CODEOWNERS file."""
        all_members = self.all_members()
        sections = (self.generate_section(s, c, all_members)
                    for s, c in self.config.sections.items())
        return join_lines(join_lines(lines) for lines in (
            self.config.manual_lines,
            self.config.config_lines,
            [_GENERATED_BEGIN, f'# {self.config.config_checksum}'],
            *sections,
            [_GENERATED_END],
        ))

    def needs_update(
        self,
        comment_mr_iid: int,
    ) -> bool:
        """Check whether the checksum matches the configuration."""
        needs_update = self.config.read_checksum != self.config.config_checksum
        # remove any left-over comments stating otherwise from the MR
        if not needs_update and comment_mr_iid:
            self.update_comment(comment_mr_iid, None)
        return needs_update

    def update(self) -> None:
        """Update the CODEOWNERS file."""
        self.config.path.write_text(self.generate(), encoding='utf8')

    def diff(
        self,
        interactive: bool,
        context_lines: int,
        comment_mr_iid: typing.Optional[int],
    ) -> int:
        """Diff current and proposed CODEOWNERS."""
        current = self.config.path.read_text(encoding='utf8')
        proposed = self.generate()

        if current == proposed:
            if comment_mr_iid:
                self.update_comment(comment_mr_iid, None)
            print('nothing to do', file=sys.stderr)
            return 0

        diff = join_lines(difflib.unified_diff(
            split_lines(current), split_lines(proposed),
            fromfile=str(self.config.path), tofile=str(self.config.path),
            n=context_lines, lineterm=''))

        if comment_mr_iid:
            body = (
                f'The `{self.config.path}` configuration was changed in this MR\n' +
                'and is inconsistent.\n\n' +
                'Please apply the following patch via `patch -p0`:\n\n' +
                '```patch\n' +
                diff +
                '```'
            )
            self.update_comment(comment_mr_iid, body)

        if interactive:
            with self._tempfile('current', current) as name1, \
                    self._tempfile('proposed', proposed) as name2:
                subprocess.run(['vimdiff', name1, name2], check=False)
            return 0

        print(diff, end='')
        return 1


def main(argv: typing.Optional[typing.List[str]] = None) -> int:
    """Run the main CLI interface."""
    parser = argparse.ArgumentParser()
    parser.add_argument('action', choices=('generate', 'update', 'diff', 'needs-update'),
                        help='configuration action')
    parser.add_argument('--config-path', type=pathlib.Path,
                        default='CODEOWNERS',
                        help='Path to the CODEOWNERS file')
    parser.add_argument('--test-path', type=pathlib.Path,
                        help='Path to output of "kpet test list -o json"')
    parser.add_argument('--context-lines', default=3, type=int,
                        help='number of context lines for the diff')
    parser.add_argument('-i', '--interactive', action='store_true',
                        help='show the differences with vimdiff')
    parser.add_argument('--comment-mr-iid', type=int,
                        help='update MR comment with the differences')
    args = parser.parse_args(argv)

    config = CodeOwnersConfig(args.config_path, args.test_path)

    if args.action == 'generate':
        print(config.generate(), end='')
        return 0

    if args.action == 'update':
        config.update()
        return 0

    if args.action == 'needs-update':
        return 1 if config.needs_update(args.comment_mr_iid) else 0

    if args.action == 'diff':
        return config.diff(args.interactive, args.context_lines,
                           args.comment_mr_iid)

    return 1


if __name__ == '__main__':
    misc.sentry_init(sentry_sdk)
    sys.exit(main())
